# 极简版多层感知机（MLP）
# 目标：用最少代码 + 非常详细注释，帮助记忆“多层感知机”的核心逻辑
# 风格：尽量贴近你写的感知机（纯 Python 列表与循环，不用 numpy）
# 结构：输入特征 → 中间计算（多个“探测器”/中间节点）→ 最终打分（再可挤压到 0~1）
# 备注：不强调名词，只强调步骤。把“负数当 0”的非线性写成一个简单函数，少记忆负担。


# 我们统一的“正规名词”（按模块分组，尽量短）
# 数据与基本元素
# 特征（feature）: 输入的各维信息，如 [1,0,1,0]。
# 样本（sample）: 一条输入数据（特征向量）。
# 标签（label）: 目标答案（如 0/1）。
# 模型结构（以感知机/MLP为核心）
# 参数（parameters）: 会被训练的旋钮，包含“权重（weight）”与“偏置（bias）”。训练就是更新它们。
# 神经元/单元（neuron/unit）: 你以前叫“探测器”；在隐藏层里叫“隐藏单元（hidden unit）”。
# 层（layer）:
# 输入层（input layer）: 放输入特征，不含可训练参数。
# 隐藏层（hidden layer）: 若干神经元，含权重与偏置。
# 输出层（output layer）: 产出模型最终分数/概率。
# 加权和（linear combination）: “特征×权重”的求和，再加偏置，是每个神经元的基础计算。
# 激活函数（activation）: 给神经元加“门”的函数，常见：
# ReLU: 负数变0，正数保持（门关/门开）。
# Sigmoid: 把分数压到0~1，便于当概率。
# Softmax: 多分类时，把多个输出映射为类别概率。
# 输出（output）:
# logit/分数: 未压缩的最终分。
# 概率: 分数经 Sigmoid/Softmax 后的0~1值。
# 训练流程与术语
# 前向传播（forward pass）: 用当前参数，把输入逐层算到输出（并缓存中间结果）。
# 损失函数（loss）: 度量“预测 vs 标签”的差异，用来驱动学习。
# 误差/残差（error）: 当前样本上“预测与标签的差”，决定本轮调参方向与力度的“全局信号”。
# 反向传播（backpropagation）: 把“误差”按“路径贡献度”分配给每个参数，得到各自的更新信号（梯度）。不是人工挑，所有参数都“被计算”，很多自然为0或很小。
# 梯度（gradient）: 每个参数“往哪调、调多少（相对量）”的信号；0=本轮不动，小=小动，大=大动。
# 学习率（learning rate）: 步长大小，把上面算好的更新“整体缩放”一下。
# 优化器（optimizer）: 用梯度更新参数的算法，如 SGD、Adam（别把优化器和反向传播混为一谈）。
# 批次/轮次（batch/epoch）: 批次=一小批样本的更新；轮次=把训练集完整过一遍。
# 随机初始化（random initialization）: 参数的起点设为小随机数，避免所有单元学成一样。
# “路径贡献度”（决定本轮改多少，四要素）
# 门是否开（激活）: 门关=这条路断，本轮不动。
# 本次神经元输出大小: 输出越大，参与越多。
# 输出层相应权重的强弱与方向: 强且把结果往“错误方向”拉→“背锅多”。
# 本次误差的大小与方向: 偏得多→整体改得多；需要往上还是往下由误差方向定。
# 输入权重的特殊点: 仅当该输入本次“参与”（特征=1/非零）时才会动明显；为0基本不动。
# 偏置: 输出层偏置几乎每轮会动；隐藏单元偏置在其门开时会动。
# 和你项目里的名字对齐（只点关键）
# w_in/b_h: 输入→隐藏层的权重/偏置（隐藏单元的内部参数）。
# w_out/b_out: 隐藏→输出层的权重/偏置（输出层参数）。
# clamp_negative_to_zero: ReLU。
# squash_to_0_1: Sigmoid。
# forward: 前向传播。
# train_one/fit: 前向→对比标签→反向传播→参数更新（含学习率）。
# 三句总记
# 模型结构（MLP）定义“怎么算分”；反向传播把“这次误差”变成“每个参数的更新量”；学习率只控“步子多大”。
# 本轮谁改多少，由“门开/输出大/输出层权重大/误差大/特征参与”共同决定；0就不动，小就小动。
# 你说的“探测器”=隐藏单元；训练保存的是“权重与偏置”，输出数值是“当次计算的结果”，不保存。
# 如果你要，我可以把这些名词映射成一张更细的“检查清单”，方便你调试每一轮谁在动、为啥动。

import random
import math


class TinyMLP:
    def __init__(self, input_dim, hidden_dim, learning_rate=0.1, seed=42):
        # input_dim: 特征个数（如：0=免费，1=链接，2=白名单）
        # hidden_dim: 中间节点个数（可理解为多个“组合特征”的评分器）
        # learning_rate: 学习率（步长）
        random.seed(seed)
        self.input_dim = input_dim
        self.hidden_dim = hidden_dim
        self.learning_rate = learning_rate

        # 1) 输入 → 中间 的权重与偏置
        #   - w_in[h][i]: 第 h 个中间节点，连接第 i 个特征的权重
        #   - b_h[h]:     第 h 个中间节点的偏置
        #   - 初始化为小随机数（起点是“瞎的”，靠训练慢慢变“懂”）
        self.w_in = [[(random.random() - 0.5) * 0.2 for _ in range(input_dim)] for _ in range(hidden_dim)]
        self.b_h = [(random.random() - 0.5) * 0.2 for _ in range(hidden_dim)]

        # 2) 中间 → 输出 的权重与偏置
        #   - w_out[h]:   第 h 个中间节点到输出的权重（它的分对最终分的影响力度）
        #   - b_out:      输出层的偏置
        self.w_out = [(random.random() - 0.5) * 0.2 for _ in range(hidden_dim)]
        self.b_out = 0.0

    # 把负数当 0（这是关键的“非线性”步骤；不记名字，只记规则：负数→0，正数→保持）
    def clamp_negative_to_zero(self, x):
        return x if x > 0 else 0.0

    # 把分数挤压到 0~1（便于当作“置信度”来理解；不想要也可直接用原分数）
    def squash_to_0_1(self, x):
        # 简单实现：Sigmoid；输出在 (0,1) 之间
        return 1.0 / (1.0 + math.exp(-x))

    # 前向计算：给一条样本 x（长度=input_dim），算出：
    #   - 每个中间节点的“原始和” → 负数当 0 → 得到“中间分”
    #   - 最终分：把所有中间分按各自输出权重相加，再加输出偏置
    #   - 同时返回中间过程，方便训练时使用
    def forward(self, x):
        hidden_raw = [0.0] * self.hidden_dim     # 每个中间节点的“原始和”（尚未负数归零）
        hidden_out = [0.0] * self.hidden_dim     # 负数当 0 后的“中间分”

        # 逐个中间节点计算“特征×权重求和 + 偏置”，再把负数当 0
        for h in range(self.hidden_dim):
            s = 0.0
            for i in range(self.input_dim):
                s += x[i] * self.w_in[h][i]
            s += self.b_h[h]
            hidden_raw[h] = s
            hidden_out[h] = self.clamp_negative_to_zero(s)  # 负数→0；正数→保持

        # 最终打分：把中间分按输出权重相加 + 输出偏置
        score = 0.0
        for h in range(self.hidden_dim):
            score += hidden_out[h] * self.w_out[h]
        score += self.b_out

        # 把最终分挤压到 0~1（更直观；如果不想当“概率”，也可以直接用 score）
        prob = self.squash_to_0_1(score)
        return hidden_raw, hidden_out, score, prob

    # 训练一条样本（极简的“反向调整”版本，不讲公式名，只讲“往对的方向推”）
    # y ∈ {0,1}：标签
    def train_one(self, x, y):
        lr = self.learning_rate
        hidden_raw, hidden_out, score, prob = self.forward(x)

        # 差距（误差方向）：
        #   - 若 y=1 且 prob 太小：希望把 prob 往上推（分更大）
        #   - 若 y=0 且 prob 太大：希望把 prob 往下拉（分更小）
        # 这里用一个简单的“方向量方向_d”，数值大小用 (prob - y) 表示“偏高/偏低程度”
        direction_d = (prob - y)  # 正数：偏高；负数：偏低

        # 1) 先改 输出层（中间 → 输出）的权重与偏置
        #    - 哪些中间分越大，对最终分影响越大，就改动越多
        for h in range(self.hidden_dim):
            # 输出权重：往能缩小差距的方向调
            #   如果 y=1 且 prob 太小（direction_d<0），需要把 w_out[h] 增大（若 hidden_out[h]>0）
            #   如果 y=0 且 prob 太大（direction_d>0），需要把 w_out[h] 减小（若 hidden_out[h]>0）
            grad_w_out = direction_d * hidden_out[h]
            self.w_out[h] -= lr * grad_w_out
        # 输出偏置：整体抬高/压低分数
        grad_b_out = direction_d * 1.0
        self.b_out -= lr * grad_b_out

        # 2) 再改 输入层（输入 → 中间）的权重与偏置
        #    - 误差会“反向传回”中间节点：若某中间节点对分数贡献大且方向不对，就要改它的输入连接
        for h in range(self.hidden_dim):
            # 这个中间节点对最终误差的“影响方向”
            #   = direction_d 乘以它连到输出的权重（它越能推动分数往错方向走，值越大）
            influence_from_output = direction_d * self.w_out[h]

            # “负数当 0”这一层的导向：
            #   - 若 hidden_raw[h] <= 0，说明这个节点当前输出为 0（被截断），它的输入改了也暂时不会影响输出，跳过即可
            #   - 若 hidden_raw[h] > 0，才需要把影响传给它的输入权重与偏置
            if hidden_raw[h] > 0:
                # 调该中间节点的偏置（整体更容易正/更难正）
                grad_b_h = influence_from_output * 1.0
                self.b_h[h] -= lr * grad_b_h

                # 调该中间节点的“输入特征权重”（哪些特征出现时更容易让它给出正分）
                for i in range(self.input_dim):
                    grad_w_in = influence_from_output * x[i]
                    self.w_in[h][i] -= lr * grad_w_in
            # hidden_raw[h] <= 0 时，保持不动（被“负数当 0”截断了）

        # 返回当前这条样本的“训练后”概率，方便观察
        _, _, _, prob_after = self.forward(x)
        return prob, prob_after

    # 训练多轮
    def fit(self, X, Y, epochs=20, verbose=True):
        for ep in range(epochs):
            total_loss_like = 0.0  # 用 |prob - y| 作一个简单的“损失感”，越小越好（仅用于打印观察）
            for x, y in zip(X, Y):
                prob_before, prob_after = self.train_one(x, y)
                total_loss_like += abs(prob_after - y)
            if verbose:
                print(f"第{ep+1}轮｜简单损失感={total_loss_like:.4f}")

    # 预测：返回“分数挤压到 0~1 之后的值”（可当作置信度）
    def predict_proba(self, x):
        _, _, _, prob = self.forward(x)
        return prob

    # 预测类别：阈值默认 0.5，可按需要调整
    def predict_label(self, x, threshold=0.5):
        p = self.predict_proba(x)
        return 1 if p >= threshold else 0


if __name__ == "__main__":
    # 一个非常小的演示数据（和你上面讨论一致的直觉）：
    # 特征索引：0=免费，1=链接，2=白名单
    # 样本含义：
    #   - [1,1,0]：有免费+链接，非白名单 → 多半垃圾（1）
    #   - [1,1,1]：有免费+链接，但白名单 → 多半正常（0）
    #   - [1,0,0]：只有免费，无链接 → 多半正常（0）
    X = [
        [1, 1, 0],
        [1, 1, 1],
        [1, 0, 0],
    ]
    Y = [1, 0, 0]

    mlp = TinyMLP(input_dim=3, hidden_dim=2, learning_rate=0.2)

    print("训练前：")
    for x in X:
        print("样本", x, "→ 置信度=", round(mlp.predict_proba(x), 3))

    mlp.fit(X, Y, epochs=20, verbose=True)

    print("\n训练后：")
    for x in X:
        p = mlp.predict_proba(x)
        y_hat = mlp.predict_label(x, threshold=0.5)
        print("样本", x, "→ 置信度=", round(p, 3), "→ 预测类别=", y_hat) 